"""Operation implementations and registry."""

from __future__ import annotations

import math
import os
import threading
from typing import Dict, Iterable, List, Optional, Tuple
from concurrent.futures import ThreadPoolExecutor

import numpy as np

from ..qt_compat import cv2
from .context import PipelineContext
from .types import OperationDefinition, ParameterDefinition
from .utils import ensure_gray, make_odd

def _color_processing(ctx: PipelineContext, mode: str) -> np.ndarray:
    bgr_source = ctx.source_bgr
    if bgr_source is None:
        current = ctx.image
        if current.ndim == 2:
            bgr_source = cv2.cvtColor(current.astype(np.float32), cv2.COLOR_GRAY2BGR)
        else:
            temp = current
            if temp.dtype != np.uint8:
                temp = cv2.normalize(temp, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
            bgr_source = temp
    if mode == "gray":
        result = ensure_gray(bgr_source)
    else:
        channel_map = {"red": 2, "green": 1, "blue": 0}
        channel_index = channel_map.get(mode, 2)
        channel = bgr_source[:, :, channel_index]
        result = channel.astype(np.float32)
    ctx.image = result.astype(np.float32)
    return ctx.image


def color_gray(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    return _color_processing(ctx, "gray")


def color_red(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    return _color_processing(ctx, "red")


def color_green(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    return _color_processing(ctx, "green")


def color_blue(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    return _color_processing(ctx, "blue")


def color_to_rgb(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    image = ctx.image
    if image.ndim == 2:
        rgb = cv2.cvtColor(image.astype(np.float32), cv2.COLOR_GRAY2RGB)
    else:
        rgb = cv2.cvtColor(image.astype(np.float32), cv2.COLOR_BGR2RGB)
    ctx.image = rgb.astype(np.float32)
    return ctx.image


def gaussian_blur(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    k = make_odd(int(params["kernel"]))
    sigma = float(params["sigma"])
    ctx.image = cv2.GaussianBlur(ctx.image, (k, k), sigmaX=sigma)
    return ctx.image


def median_blur(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    k = make_odd(int(params["kernel"]))
    ctx.image = cv2.medianBlur(ctx.image.astype(np.uint8), k).astype(np.float32)
    return ctx.image


def clahe_filter(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    clip = float(params["clip"])
    tiles = int(params["tiles"])
    clahe = cv2.createCLAHE(clipLimit=clip, tileGridSize=(tiles, tiles))
    img_u8 = cv2.normalize(ctx.image, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
    ctx.image = clahe.apply(img_u8).astype(np.float32)
    return ctx.image


def illumination_normalization(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    k = make_odd(int(params["kernel"]))
    sigma = float(params["sigma"])
    strength = float(params["strength"])
    blurred = cv2.GaussianBlur(ctx.image, (k, k), sigma)
    normalized = cv2.divide(ctx.image, blurred + 1e-3)
    normalized = cv2.normalize(normalized, None, 0, 255, cv2.NORM_MINMAX)
    ctx.image = cv2.addWeighted(ctx.image, 1.0 - strength, normalized, strength, 0)
    return ctx.image


def contrast_adjust(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    image = ctx.image.astype(np.float32)
    global_contrast = float(params["contrast"])
    local_amount = float(params["local_amount"])
    radius = int(params["local_radius"])

    if image.ndim == 2:
        mean = np.mean(image)
    else:
        mean = np.mean(image, axis=(0, 1), keepdims=True)
    adjusted = (image - mean) * global_contrast + mean

    if local_amount > 0.0 and radius > 0:
        kernel = make_odd(radius)
        blurred = cv2.GaussianBlur(adjusted, (kernel, kernel), 0)
        # Unsharp masking to boost local contrast while keeping overall tone
        adjusted = cv2.addWeighted(adjusted, 1.0 + local_amount, blurred, -local_amount, 0)

    adjusted = np.clip(adjusted, 0.0, 255.0)
    ctx.image = adjusted.astype(np.float32)
    return ctx.image


def sobel_operator(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    k = make_odd(int(params["kernel"]))
    orientation = params["orientation"]
    grad_x = cv2.Sobel(ctx.image, cv2.CV_32F, 1, 0, ksize=k)
    grad_y = cv2.Sobel(ctx.image, cv2.CV_32F, 0, 1, ksize=k)
    if orientation == "x":
        ctx.image = np.abs(grad_x)
    elif orientation == "y":
        ctx.image = np.abs(grad_y)
    else:
        ctx.image = cv2.magnitude(grad_x, grad_y)
    return ctx.image


def canny_operator(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    t1 = float(params["threshold1"])
    t2 = float(params["threshold2"])
    blurred = cv2.GaussianBlur(ctx.image, (5, 5), 1.0)
    edges = cv2.Canny(cv2.normalize(blurred, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8), t1, t2)
    ctx.image = edges.astype(np.float32)
    return ctx.image


def log_operator(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    k = make_odd(int(params["kernel"]))
    sigma = float(params["sigma"])
    blurred = cv2.GaussianBlur(ctx.image, (k, k), sigma)
    log = cv2.Laplacian(blurred, cv2.CV_32F, ksize=k)
    ctx.image = cv2.convertScaleAbs(log).astype(np.float32)
    return ctx.image


def dog_operator(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    sigma1 = float(params["sigma1"])
    sigma2 = float(params["sigma2"])
    g1 = cv2.GaussianBlur(ctx.image, (0, 0), sigma1)
    g2 = cv2.GaussianBlur(ctx.image, (0, 0), sigma2)
    ctx.image = g1 - g2
    return ctx.image


def fourier_transform(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    freq = ctx.ensure_frequency()
    magnitude = np.log(cv2.magnitude(freq[:, :, 0], freq[:, :, 1]) + 1.0)
    ctx.image = ctx.fourier_source if ctx.fourier_source is not None else ctx.image
    return magnitude


def inverse_fourier(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    result = ctx.update_from_frequency()
    ctx.fourier_shift = None
    ctx.fourier_source = None
    return result


def apply_frequency_mask(ctx: PipelineContext, mask: np.ndarray) -> np.ndarray:
    freq = ctx.ensure_frequency()
    freq *= mask
    ctx.fourier_shift = freq
    spatial = ctx.update_from_frequency()
    return spatial


def notch_filter(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    freq = ctx.ensure_frequency()
    rows, cols = freq.shape[:2]
    mask = np.ones((rows, cols, 2), dtype=np.float32)
    radius = int(params["radius"])
    offset_x = int(params["offset_x"])
    offset_y = int(params["offset_y"])
    centers = [
        (rows // 2 + offset_y, cols // 2 + offset_x),
        (rows // 2 - offset_y, cols // 2 - offset_x),
    ]
    for cy, cx in centers:
        cv2.circle(mask, (cx, cy), radius, (0, 0), -1)
    return apply_frequency_mask(ctx, mask)


def bandpass_filter(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    freq = ctx.ensure_frequency()
    rows, cols = freq.shape[:2]
    center_y, center_x = rows // 2, cols // 2
    y = np.arange(rows) - center_y
    x = np.arange(cols) - center_x
    xx, yy = np.meshgrid(x, y)
    dist = np.sqrt(xx**2 + yy**2)
    low = float(params["low"])
    high = float(params["high"])
    mask = np.zeros_like(freq, dtype=np.float32)
    region = (dist >= low) & (dist <= high)
    mask[region] = 1.0
    return apply_frequency_mask(ctx, mask)


def gaussian_lowpass(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    freq = ctx.ensure_frequency()
    rows, cols = freq.shape[:2]
    center_y, center_x = rows // 2, cols // 2
    y = np.arange(rows) - center_y
    x = np.arange(cols) - center_x
    xx, yy = np.meshgrid(x, y)
    dist_sq = xx**2 + yy**2
    cutoff = float(params["cutoff"])
    mask = np.exp(-dist_sq / (2 * (cutoff**2)))
    mask = np.repeat(mask[:, :, None], 2, axis=2).astype(np.float32)
    return apply_frequency_mask(ctx, mask)


def gaussian_highpass(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    freq = ctx.ensure_frequency()
    rows, cols = freq.shape[:2]
    center_y, center_x = rows // 2, cols // 2
    y = np.arange(rows) - center_y
    x = np.arange(cols) - center_x
    xx, yy = np.meshgrid(x, y)
    dist_sq = xx**2 + yy**2
    cutoff = float(params["cutoff"])
    mask = 1.0 - np.exp(-dist_sq / (2 * (cutoff**2)))
    mask = np.repeat(mask[:, :, None], 2, axis=2).astype(np.float32)
    return apply_frequency_mask(ctx, mask)


def remove_dominant_direction(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    """Estimate the dominant fiber direction and inpaint along that orientation."""
    image = ensure_gray(ctx.image)
    image_u8 = cv2.normalize(image, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
    normalized = image_u8.astype(np.float32) / 255.0

    ksize = make_odd(int(params["kernel"]))
    sigma = float(params["sigma"])
    wavelength = float(params["wavelength"])
    gamma = float(params["gamma"])
    psi = math.radians(float(params["psi"]))
    samples = max(1, int(params["samples"]))
    response_quantile = float(params.get("response_quantile", 0.85))
    response_quantile = min(max(response_quantile, 0.0), 0.999)
    dilation_iters = max(0, int(params.get("dilation_iterations", 1)))
    inpaint_radius = float(params.get("inpaint_radius", 3.0))
    blend = float(params.get("blend", 0.0))
    blend = min(max(blend, 0.0), 1.0)

    best_filtered: Optional[np.ndarray] = None
    best_score = -np.inf
    best_theta = 0.0
    for index in range(samples):
        theta = (index / samples) * math.pi
        kernel = cv2.getGaborKernel((ksize, ksize), sigma, theta, wavelength, gamma, psi, ktype=cv2.CV_32F)
        filtered = cv2.filter2D(normalized, cv2.CV_32F, kernel)
        score = float(np.mean(np.abs(filtered)))
        if score > best_score:
            best_score = score
            best_filtered = filtered
            best_theta = theta

    if best_filtered is None:
        best_filtered = normalized
    response = np.abs(best_filtered)
    response = cv2.GaussianBlur(response, (3, 3), 0)
    response_norm = cv2.normalize(response, None, 0.0, 1.0, cv2.NORM_MINMAX)
    threshold_value = float(np.quantile(response_norm, response_quantile))
    if threshold_value <= 0.0:
        threshold_value = float(np.mean(response_norm))
    mask = (response_norm >= threshold_value).astype(np.uint8)
    if dilation_iters > 0:
        kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
        mask = cv2.dilate(mask, kernel, iterations=dilation_iters)
    mask_u8 = (mask * 255).astype(np.uint8)
    if not np.any(mask_u8):
        ctx.image = image_u8.astype(np.float32)
        return ctx.image

    inpainted = cv2.inpaint(image_u8, mask_u8, inpaint_radius, cv2.INPAINT_TELEA)
    if blend > 0.0:
        blended = cv2.addWeighted(inpainted.astype(np.float32), 1.0 - blend, image_u8.astype(np.float32), blend, 0.0)
    else:
        blended = inpainted.astype(np.float32)

    ctx.image = blended
    ctx.last_dominant_angle = math.degrees(best_theta)
    return ctx.image


def highlight_mask_on_source(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    mask_input = cv2.normalize(ctx.image, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
    threshold = float(params["threshold"])
    _, binary = cv2.threshold(mask_input, threshold, 255, cv2.THRESH_BINARY)
    mask = binary > 0

    bgr_source = ctx.source_bgr
    if bgr_source is None:
        current = ctx.image
        if current.ndim == 2:
            bgr_source = cv2.cvtColor(current.astype(np.float32), cv2.COLOR_GRAY2BGR)
        else:
            temp = current
            if temp.dtype != np.uint8:
                temp = cv2.normalize(temp, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
            bgr_source = temp
    base = bgr_source.astype(np.float32).copy()

    color_name = params["color"]
    color_map = {
        "red": (0.0, 0.0, 255.0),
        "green": (0.0, 255.0, 0.0),
        "blue": (255.0, 0.0, 0.0),
        "yellow": (0.0, 255.0, 255.0),
        "magenta": (255.0, 0.0, 255.0),
        "cyan": (255.0, 255.0, 0.0),
    }
    highlight_color = np.array(color_map.get(color_name, color_map["red"]), dtype=np.float32)
    alpha = float(np.clip(float(params["alpha"]), 0.0, 1.0))

    base[mask] = (1.0 - alpha) * base[mask] + alpha * highlight_color
    ctx.image = base.astype(np.float32)
    return ctx.image


def gabor_filter(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    ksize = make_odd(int(params["kernel"]))
    sigma = float(params["sigma"])
    theta = math.radians(float(params["theta"]))
    lambd = float(params["wavelength"])
    gamma = float(params["gamma"])
    psi = math.radians(float(params["psi"]))
    kernel = cv2.getGaborKernel((ksize, ksize), sigma, theta, lambd, gamma, psi, ktype=cv2.CV_32F)
    filtered = cv2.filter2D(ctx.image, cv2.CV_32F, kernel)
    ctx.image = filtered
    return ctx.image


def multi_directional_gabor(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    base_gray = ensure_gray(ctx.image)
    directions = max(1, int(params["directions"]))
    angle_start = float(params["angle_start"])
    angle_step = float(params["angle_step"])
    kernel_size = make_odd(int(params["kernel"]))
    sigma = float(params["sigma"])
    wavelength = float(params["wavelength"])
    gamma = float(params["gamma"])
    psi = float(params["psi"])
    cutoff = float(params["highpass_cutoff"])
    strong_threshold = float(params.get("strong_threshold", params.get("binary_threshold", 128.0)))
    weak_threshold = float(params.get("weak_threshold", strong_threshold * 0.75))
    high_threshold = float(np.clip(max(strong_threshold, weak_threshold), 0.0, 255.0))
    low_threshold = float(np.clip(min(strong_threshold, weak_threshold), 0.0, 255.0))
    use_hysteresis_raw = params.get("use_hysteresis", True)
    use_hysteresis = use_hysteresis_raw if isinstance(use_hysteresis_raw, bool) else str(use_hysteresis_raw).lower() in {
        "1",
        "true",
        "yes",
        "on",
    }
    close_mask = bool(params.get("dilate_mask", False))
    closing_iterations = max(1, int(params.get("dilation_iterations", 1)))
    use_chunks = bool(params.get("use_chunks", False))
    chunk_size = max(16, int(params.get("chunk_size", 256)))
    exclude_spec = str(params.get("exclude_angles", "") or "").strip()
    parallel_kernels_raw = params.get("parallel_kernels", True)
    if isinstance(parallel_kernels_raw, bool):
        parallel_kernels = parallel_kernels_raw
    elif isinstance(parallel_kernels_raw, str):
        parallel_kernels = parallel_kernels_raw.strip().lower() in {"1", "true", "yes", "on"}
    else:
        parallel_kernels = bool(parallel_kernels_raw)
    kernel_workers_raw = params.get("kernel_workers", 0)
    try:
        kernel_workers = int(kernel_workers_raw)
    except (TypeError, ValueError):
        kernel_workers = 0
    kernel_workers = max(0, kernel_workers)

    def normalize_angle(angle: float) -> float:
        angle = angle % 180.0
        if angle < 0:
            angle += 180.0
        return angle

    def parse_exclusions(spec: str) -> Tuple[List[float], List[Tuple[float, float]]]:
        singles: List[float] = []
        ranges: List[Tuple[float, float]] = []
        if not spec:
            return singles, ranges
        for token in spec.split(","):
            token = token.strip()
            if not token:
                continue
            if "-" in token:
                start_text, end_text = token.split("-", 1)
                try:
                    start = normalize_angle(float(start_text.strip()))
                    end = normalize_angle(float(end_text.strip()))
                except ValueError:
                    continue
                if start <= end:
                    ranges.append((start, end))
                else:
                    # handle wrap by splitting into two ranges
                    ranges.append((start, 180.0))
                    ranges.append((0.0, end))
            else:
                try:
                    singles.append(normalize_angle(float(token)))
                except ValueError:
                    continue
        return singles, ranges

    exclude_singles, exclude_ranges = parse_exclusions(exclude_spec)

    def should_exclude(angle: float) -> bool:
        norm_angle = normalize_angle(angle)
        for single in exclude_singles:
            if abs(norm_angle - single) <= 1e-6:
                return True
        for start, end in exclude_ranges:
            if start <= norm_angle <= end:
                return True
        return False

    angles: List[float] = []
    for index in range(directions):
        angle = (angle_start + angle_step * index) % 180.0
        if not should_exclude(angle):
            angles.append(angle)
    if not angles:
        angles.append(normalize_angle(angle_start))

    setattr(ctx, "multi_gabor_angles", angles)

    psi_rad = math.radians(psi)
    kernels: List[np.ndarray] = []
    for angle in angles:
        theta_rad = math.radians(angle)
        kernel_mat = cv2.getGaborKernel(
            (kernel_size, kernel_size),
            sigma,
            theta_rad,
            wavelength,
            gamma,
            psi_rad,
            ktype=cv2.CV_32F,
        )
        kernels.append(kernel_mat)

    morph_kernel = None
    if close_mask:
        morph_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))

    mask_cache: Dict[Tuple[int, int], np.ndarray] = {}

    def get_highpass_mask(shape: Tuple[int, int]) -> np.ndarray:
        key = (shape[0], shape[1])
        cached = mask_cache.get(key)
        if cached is not None:
            return cached
        rows, cols = shape
        center_y, center_x = rows // 2, cols // 2
        y = np.arange(rows, dtype=np.float32) - center_y
        x = np.arange(cols, dtype=np.float32) - center_x
        xx, yy = np.meshgrid(x, y)
        dist_sq = xx**2 + yy**2
        hp = 1.0 - np.exp(-dist_sq / (2 * (cutoff**2)))
        mask = np.repeat(hp[:, :, None], 2, axis=2).astype(np.float32)
        mask_cache[key] = mask
        return mask

    def apply_highpass(image: np.ndarray, mask: np.ndarray) -> np.ndarray:
        freq = cv2.dft(image, flags=cv2.DFT_COMPLEX_OUTPUT)
        freq_shift = np.fft.fftshift(freq)
        freq_shift *= mask
        ishift = np.fft.ifftshift(freq_shift)
        img_back = cv2.idft(ishift)
        return cv2.magnitude(img_back[:, :, 0], img_back[:, :, 1])

    def normalize_response(image: np.ndarray) -> np.ndarray:
        return cv2.normalize(image, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)

    def build_simple_mask(image: np.ndarray) -> np.ndarray:
        normalized = normalize_response(image)
        if normalized.max() == 0:
            return np.zeros_like(normalized, dtype=np.float32)
        _, binary = cv2.threshold(normalized, high_threshold, 255, cv2.THRESH_BINARY)
        return binary.astype(np.float32)

    def build_hysteresis_mask(image: np.ndarray) -> np.ndarray:
        normalized = normalize_response(image)
        if normalized.max() == 0:
            return np.zeros_like(normalized, dtype=np.float32)
        _, strong_mask = cv2.threshold(normalized, high_threshold, 255, cv2.THRESH_BINARY)
        _, weak_mask = cv2.threshold(normalized, low_threshold, 255, cv2.THRESH_BINARY)
        if strong_mask.max() == 0 or weak_mask.max() == 0:
            return np.zeros_like(normalized, dtype=np.float32)
        num_labels, labels = cv2.connectedComponents(weak_mask, connectivity=8)
        if num_labels <= 1:
            return np.zeros_like(normalized, dtype=np.float32)
        strong_labels = labels[(strong_mask > 0) & (weak_mask > 0)]
        strong_labels = strong_labels[strong_labels > 0]
        if strong_labels.size == 0:
            return np.zeros_like(normalized, dtype=np.float32)
        final_mask = np.zeros_like(normalized, dtype=np.uint8)
        for label in np.unique(strong_labels):
            final_mask[labels == label] = 255
        final_mask[strong_mask > 0] = 255
        return final_mask.astype(np.float32)

    kernel_executor: Optional[ThreadPoolExecutor] = None
    kernel_executor_lock = threading.Lock()

    def ensure_kernel_executor() -> ThreadPoolExecutor:
        nonlocal kernel_executor
        if kernel_executor is None:
            with kernel_executor_lock:
                if kernel_executor is None:
                    auto_workers = max(1, (os.cpu_count() or 1) - 1)
                    max_workers = kernel_workers if kernel_workers > 0 else min(len(kernels), auto_workers)
                    max_workers = max(1, max_workers)
                    kernel_executor = ThreadPoolExecutor(max_workers=max_workers)
        return kernel_executor

    def process_tile(tile: np.ndarray) -> np.ndarray:
        tile_base = np.ascontiguousarray(tile.astype(np.float32))
        freq_mask = get_highpass_mask(tile_base.shape)
        combined: Optional[np.ndarray] = None
        def run_kernel(kernel_mat: np.ndarray) -> np.ndarray:
            filtered = cv2.filter2D(tile_base, cv2.CV_32F, kernel_mat)
            highpassed = apply_highpass(filtered, freq_mask)
            if use_hysteresis:
                mask = build_hysteresis_mask(highpassed)
            else:
                mask = build_simple_mask(highpassed)
            if close_mask and morph_kernel is not None:
                mask_u8 = mask.astype(np.uint8)
                closed = cv2.morphologyEx(mask_u8, cv2.MORPH_CLOSE, morph_kernel, iterations=closing_iterations)
                mask = closed.astype(np.float32)
            return mask

        if parallel_kernels and len(kernels) > 1:
            executor = ensure_kernel_executor()
            futures = [executor.submit(run_kernel, kernel_mat) for kernel_mat in kernels]
            for future in futures:
                mask = future.result()
                if combined is None:
                    combined = mask
                else:
                    np.maximum(combined, mask, out=combined)
        else:
            for kernel_mat in kernels:
                mask = run_kernel(kernel_mat)
                if combined is None:
                    combined = mask
                else:
                    np.maximum(combined, mask, out=combined)
        if combined is None:
            combined = np.zeros_like(tile_base, dtype=np.float32)
        return combined

    if use_chunks:
        height, width = base_gray.shape
        output = np.zeros_like(base_gray, dtype=np.float32)
        futures: List[Tuple] = []
        with ThreadPoolExecutor() as executor:
            for y in range(0, height, chunk_size):
                for x in range(0, width, chunk_size):
                    tile = base_gray[y : y + chunk_size, x : x + chunk_size]
                    futures.append(
                        (
                            executor.submit(process_tile, tile.copy()),
                            y,
                            x,
                            tile.shape[0],
                            tile.shape[1],
                        )
                    )
            for future, y, x, h, w in futures:
                result_tile = future.result()
                output[y : y + h, x : x + w] = result_tile[:h, :w]
        ctx.image = output
    else:
        result = process_tile(base_gray)
        ctx.image = result

    if kernel_executor is not None:
        kernel_executor.shutdown(wait=True)

    ctx.image = np.clip(ctx.image, 0.0, 255.0).astype(np.float32)
    return ctx.image


def hough_transform(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    threshold = int(params["threshold"])
    min_line = int(params["min_length"])
    max_gap = int(params["max_gap"])
    edges = cv2.Canny(cv2.normalize(ctx.image, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8), 50, 150)
    lines = cv2.HoughLinesP(edges, 1, np.pi / 180, threshold, minLineLength=min_line, maxLineGap=max_gap)
    color = cv2.cvtColor(edges, cv2.COLOR_GRAY2BGR)
    if lines is not None:
        for line in lines:
            x1, y1, x2, y2 = line[0]
            cv2.line(color, (x1, y1), (x2, y2), (0, 0, 255), 2)
    ctx.image = cv2.cvtColor(color, cv2.COLOR_BGR2GRAY).astype(np.float32)
    return color


def binary_threshold(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    thresh = float(params["threshold"])
    _, binary = cv2.threshold(
        cv2.normalize(ctx.image, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8),
        thresh,
        255,
        cv2.THRESH_BINARY,
    )
    ctx.image = binary.astype(np.float32)
    return ctx.image


def brightness_threshold(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    threshold = float(params["threshold"])
    image = ctx.image
    mask = image >= threshold
    ctx.image = np.where(mask, image, 0).astype(np.float32)
    return ctx.image


def _component_retain_mask(mask: np.ndarray, min_size: int) -> np.ndarray:
    if mask.max() == 0:
        return np.zeros_like(mask, dtype=np.uint8)
    num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(mask, connectivity=8)
    retain_mask = np.ones_like(mask, dtype=np.uint8)
    for label in range(1, num_labels):
        area = stats[label, cv2.CC_STAT_AREA]
        if area < min_size:
            retain_mask[labels == label] = 0
    return retain_mask


def remove_small_components(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    min_size = int(params["min_size"])
    by_chunk_raw = params.get("by_chunk", False)
    if isinstance(by_chunk_raw, str):
        by_chunk = by_chunk_raw.strip().lower() in {"true", "1", "yes", "on"}
    else:
        by_chunk = bool(by_chunk_raw)
    chunk_size = int(params.get("chunk_size", 640))
    chunk_size = max(1, chunk_size)

    image = ctx.image.astype(np.float32)
    mask = (image > 0).astype(np.uint8)

    if not by_chunk:
        retain_mask = _component_retain_mask(mask, min_size)
        ctx.image = image * retain_mask.astype(np.float32)
        return ctx.image

    height, width = mask.shape[:2]
    output = np.zeros_like(image, dtype=np.float32)

    for y in range(0, height, chunk_size):
        y_end = min(y + chunk_size, height)
        for x in range(0, width, chunk_size):
            x_end = min(x + chunk_size, width)
            chunk_mask = mask[y:y_end, x:x_end]
            if chunk_mask.max() == 0:
                continue
            retain_chunk = _component_retain_mask(chunk_mask, min_size)
            chunk_image = image[y:y_end, x:x_end]
            output[y:y_end, x:x_end] = chunk_image * retain_chunk.astype(np.float32)

    ctx.image = output
    return ctx.image


def _threshold_reference(
    image: np.ndarray,
    mode: str,
    manual_value: float,
    window_size: int,
) -> np.ndarray:
    data = image.astype(np.float32)
    if mode == "manual":
        return np.full_like(data, float(manual_value), dtype=np.float32)
    if mode == "local_mean":
        k = make_odd(max(1, window_size))
        return cv2.blur(data, (k, k))
    mean_val = float(np.mean(data))
    return np.full_like(data, mean_val, dtype=np.float32)


def lower_threshold_limit(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    mode = params["mode"]
    manual_value = float(params["manual_value"])
    window_size = int(params["window"])
    image = ctx.image.astype(np.float32)
    threshold_map = _threshold_reference(image, mode, manual_value, window_size)
    ctx.image = np.where(image >= threshold_map, image, 0.0).astype(np.float32)
    return ctx.image


def upper_threshold_limit(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    mode = params["mode"]
    manual_value = float(params["manual_value"])
    window_size = int(params["window"])
    image = ctx.image.astype(np.float32)
    threshold_map = _threshold_reference(image, mode, manual_value, window_size)
    ctx.image = np.where(image <= threshold_map, image, 0.0).astype(np.float32)
    return ctx.image


def _resolve_kernel_dims(params: Dict[str, object]) -> Tuple[int, int]:
    base = int(params.get("kernel", 3))
    width = int(params.get("kernel_width", base))
    height = int(params.get("kernel_height", params.get("kernel", width)))
    return width, height


def _morph_kernel(width: int, height: int) -> np.ndarray:
    w = max(1, make_odd(width))
    h = max(1, make_odd(height))
    return cv2.getStructuringElement(cv2.MORPH_RECT, (w, h))


def _morph_prepare(image: np.ndarray) -> np.ndarray:
    if image.dtype != np.uint8:
        return cv2.normalize(image, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
    return image


def morphology_erode(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    kernel_width, kernel_height = _resolve_kernel_dims(params)
    iterations = int(params["iterations"])
    kernel = _morph_kernel(kernel_width, kernel_height)
    prepared = _morph_prepare(ctx.image)
    result = cv2.erode(prepared, kernel, iterations=iterations)
    ctx.image = result.astype(np.float32)
    return ctx.image


def morphology_dilate(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    kernel_width, kernel_height = _resolve_kernel_dims(params)
    iterations = int(params["iterations"])
    kernel = _morph_kernel(kernel_width, kernel_height)
    prepared = _morph_prepare(ctx.image)
    result = cv2.dilate(prepared, kernel, iterations=iterations)
    ctx.image = result.astype(np.float32)
    return ctx.image


def morphology_open(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    kernel_width, kernel_height = _resolve_kernel_dims(params)
    iterations = int(params["iterations"])
    kernel = _morph_kernel(kernel_width, kernel_height)
    prepared = _morph_prepare(ctx.image)
    result = cv2.morphologyEx(prepared, cv2.MORPH_OPEN, kernel, iterations=iterations)
    ctx.image = result.astype(np.float32)
    return ctx.image


def morphology_close(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    kernel_width, kernel_height = _resolve_kernel_dims(params)
    iterations = int(params["iterations"])
    kernel = _morph_kernel(kernel_width, kernel_height)
    prepared = _morph_prepare(ctx.image)
    result = cv2.morphologyEx(prepared, cv2.MORPH_CLOSE, kernel, iterations=iterations)
    ctx.image = result.astype(np.float32)
    return ctx.image


def minimum_filter(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    kernel_width, kernel_height = _resolve_kernel_dims(params)
    kernel = _morph_kernel(kernel_width, kernel_height)
    prepared = _morph_prepare(ctx.image)
    result = cv2.erode(prepared, kernel, iterations=1)
    ctx.image = result.astype(np.float32)
    return ctx.image


def maximum_filter(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    kernel_width, kernel_height = _resolve_kernel_dims(params)
    kernel = _morph_kernel(kernel_width, kernel_height)
    prepared = _morph_prepare(ctx.image)
    result = cv2.dilate(prepared, kernel, iterations=1)
    ctx.image = result.astype(np.float32)
    return ctx.image


def spatial_lowpass(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    k = make_odd(int(params["kernel"]))
    ctx.image = cv2.blur(ctx.image, (k, k))
    return ctx.image


def spatial_highpass(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    k = make_odd(int(params["kernel"]))
    blurred = cv2.GaussianBlur(ctx.image, (k, k), 0)
    high = ctx.image - blurred
    ctx.image = high
    return ctx.image


def bilateral_filter(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    d = int(params["diameter"])
    sigma_color = float(params["sigma_color"])
    sigma_space = float(params["sigma_space"])
    img_u8 = cv2.normalize(ctx.image, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
    filtered = cv2.bilateralFilter(img_u8, d, sigma_color, sigma_space)
    ctx.image = filtered.astype(np.float32)
    return ctx.image


def difference_of_gaussian_fourier(ctx: PipelineContext, params: Dict[str, object]) -> np.ndarray:
    low = gaussian_lowpass(ctx, params)
    ctx.ensure_frequency()  # keeps filtered frequency for potential further steps
    return low


def _morph_parameter_definitions(include_iterations: bool = True) -> List[ParameterDefinition]:
    params = [
        ParameterDefinition("kernel_width", "Kernel Width", "int", 1, 31, 3, 2),
        ParameterDefinition("kernel_height", "Kernel Height", "int", 1, 31, 3, 2),
    ]
    if include_iterations:
        params.append(ParameterDefinition("iterations", "Iterations", "int", 1, 10, 1, 1))
    return params



OPERATIONS: List[OperationDefinition] = [
    OperationDefinition(
        key="color_gray",
        label="Grayscale",
        category="Color Processing",
        parameters=[],
        apply_func=color_gray,
    ),
    OperationDefinition(
        key="color_red",
        label="Red Channel",
        category="Color Processing",
        parameters=[],
        apply_func=color_red,
    ),
    OperationDefinition(
        key="color_green",
        label="Green Channel",
        category="Color Processing",
        parameters=[],
        apply_func=color_green,
    ),
    OperationDefinition(
        key="color_blue",
        label="Blue Channel",
        category="Color Processing",
        parameters=[],
        apply_func=color_blue,
    ),
    OperationDefinition(
        key="color_to_rgb",
        label="To RGB",
        category="Color Processing",
        parameters=[],
        apply_func=color_to_rgb,
    ),
    OperationDefinition(
        key="morph_erode",
        label="Erode",
        category="Morphology",
        parameters=_morph_parameter_definitions(),
        apply_func=morphology_erode,
    ),
    OperationDefinition(
        key="morph_dilate",
        label="Dilate",
        category="Morphology",
        parameters=_morph_parameter_definitions(),
        apply_func=morphology_dilate,
    ),
    OperationDefinition(
        key="morph_open",
        label="Opening",
        category="Morphology",
        parameters=_morph_parameter_definitions(),
        apply_func=morphology_open,
    ),
    OperationDefinition(
        key="morph_close",
        label="Closing",
        category="Morphology",
        parameters=_morph_parameter_definitions(),
        apply_func=morphology_close,
    ),
    OperationDefinition(
        key="minimum_filter",
        label="Minimum Filter",
        category="Morphology",
        parameters=_morph_parameter_definitions(include_iterations=False),
        apply_func=minimum_filter,
    ),
    OperationDefinition(
        key="maximum_filter",
        label="Maximum Filter",
        category="Morphology",
        parameters=_morph_parameter_definitions(include_iterations=False),
        apply_func=maximum_filter,
    ),
    OperationDefinition(
        key="gaussian_blur",
        label="Gaussian Blur",
        category="Smoothing",
        parameters=[
            ParameterDefinition("kernel", "Kernel Size", "int", 1, 31, 5, 2),
            ParameterDefinition("sigma", "Sigma", "float", 0.0, 10.0, 1.0, 0.1, decimals=2),
        ],
        apply_func=gaussian_blur,
    ),
    OperationDefinition(
        key="median_blur",
        label="Median Filter",
        category="Smoothing",
        parameters=[
            ParameterDefinition("kernel", "Kernel Size", "int", 1, 31, 5, 2),
        ],
        apply_func=median_blur,
    ),
    OperationDefinition(
        key="clahe",
        label="CLAHE Equalization",
        category="Luminance",
        parameters=[
            ParameterDefinition("clip", "Clip Limit", "float", 1.0, 8.0, 2.0, 0.1, decimals=2),
            ParameterDefinition("tiles", "Tile Size", "int", 2, 16, 8, 1),
        ],
        apply_func=clahe_filter,
    ),
    OperationDefinition(
        key="illumination",
        label="Illumination Normalization",
        category="Luminance",
        parameters=[
            ParameterDefinition("kernel", "Blur Kernel Size", "int", 3, 51, 15, 2),
            ParameterDefinition("sigma", "Blur Sigma", "float", 0.0, 20.0, 3.0, 0.5, decimals=1),
            ParameterDefinition("strength", "Blend Strength", "float", 0.0, 1.0, 0.5, 0.05, decimals=2),
        ],
        apply_func=illumination_normalization,
    ),
    OperationDefinition(
        key="contrast_adjust",
        label="Contrast Adjustment",
        category="Luminance",
        parameters=[
            ParameterDefinition("contrast", "Global Contrast", "float", 0.2, 3.0, 1.0, 0.1, decimals=2),
            ParameterDefinition("local_amount", "Local Contrast Amount", "float", 0.0, 2.0, 0.0, 0.05, decimals=2),
            ParameterDefinition("local_radius", "Local Contrast Radius", "int", 1, 51, 9, 2),
        ],
        apply_func=contrast_adjust,
    ),
    OperationDefinition(
        key="spatial_lowpass",
        label="Spatial Low-pass Filter",
        category="Smoothing",
        parameters=[
            ParameterDefinition("kernel", "Kernel Size", "int", 1, 31, 5, 2),
        ],
        apply_func=spatial_lowpass,
    ),
    OperationDefinition(
        key="bilateral",
        label="Bilateral Filter",
        category="Smoothing",
        parameters=[
            ParameterDefinition("diameter", "Neighborhood Diameter", "int", 1, 20, 9, 1),
            ParameterDefinition("sigma_color", "Color Sigma", "float", 1.0, 200.0, 75.0, 1.0, decimals=1),
            ParameterDefinition("sigma_space", "Spatial Sigma", "float", 1.0, 200.0, 75.0, 1.0, decimals=1),
        ],
        apply_func=bilateral_filter,
    ),
    OperationDefinition(
        key="sobel",
        label="Sobel Operator",
        category="Edge Detection",
        parameters=[
            ParameterDefinition("kernel", "Kernel Size", "int", 1, 7, 3, 2),
            ParameterDefinition(
                "orientation",
                "Orientation",
                "choice",
                choices=[("X Direction", "x"), ("Y Direction", "y"), ("Gradient Magnitude", "magnitude")],
                default="magnitude",
            ),
        ],
        apply_func=sobel_operator,
    ),
    OperationDefinition(
        key="canny",
        label="Canny Edge",
        category="Edge Detection",
        parameters=[
            ParameterDefinition("threshold1", "Low Threshold", "float", 0.0, 255.0, 50.0, 1.0, decimals=1),
            ParameterDefinition("threshold2", "High Threshold", "float", 0.0, 255.0, 150.0, 1.0, decimals=1),
        ],
        apply_func=canny_operator,
    ),
    OperationDefinition(
        key="log",
        label="LoG Operator",
        category="Edge Detection",
        parameters=[
            ParameterDefinition("kernel", "Kernel Size", "int", 3, 15, 5, 2),
            ParameterDefinition("sigma", "Sigma", "float", 0.1, 5.0, 1.0, 0.1, decimals=2),
        ],
        apply_func=log_operator,
    ),
    OperationDefinition(
        key="dog",
        label="Difference of Gaussians",
        category="Edge Detection",
        parameters=[
            ParameterDefinition("sigma1", "Sigma1", "float", 0.1, 10.0, 1.0, 0.1, decimals=2),
            ParameterDefinition("sigma2", "Sigma2", "float", 0.1, 10.0, 2.0, 0.1, decimals=2),
        ],
        apply_func=dog_operator,
    ),
    OperationDefinition(
        key="spatial_highpass",
        label="Spatial High-pass Filter",
        category="Edge Detection",
        parameters=[
            ParameterDefinition("kernel", "Kernel Size", "int", 1, 31, 5, 2),
        ],
        apply_func=spatial_highpass,
    ),
    OperationDefinition(
        key="fft",
        label="Fourier Transform",
        category="Fourier",
        parameters=[],
        apply_func=fourier_transform,
    ),
    OperationDefinition(
        key="ifft",
        label="Inverse Fourier Transform",
        category="Fourier",
        parameters=[],
        apply_func=inverse_fourier,
    ),
    OperationDefinition(
        key="notch",
        label="Notch Filter",
        category="Fourier",
        parameters=[
            ParameterDefinition("radius", "Radius", "int", 1, 50, 10, 1),
            ParameterDefinition("offset_x", "X Offset", "int", 0, 200, 30, 1),
            ParameterDefinition("offset_y", "Y Offset", "int", 0, 200, 0, 1),
        ],
        apply_func=notch_filter,
    ),
    OperationDefinition(
        key="bandpass",
        label="Band-pass Filter",
        category="Fourier",
        parameters=[
            ParameterDefinition("low", "Low Cutoff", "float", 0.0, 200.0, 10.0, 1.0, decimals=1),
            ParameterDefinition("high", "High Cutoff", "float", 0.0, 300.0, 60.0, 1.0, decimals=1),
        ],
        apply_func=bandpass_filter,
    ),
    OperationDefinition(
        key="gauss_lowpass",
        label="Gaussian Low-pass Filter",
        category="Fourier",
        parameters=[
            ParameterDefinition("cutoff", "Cutoff Frequency", "float", 1.0, 200.0, 30.0, 1.0, decimals=1),
        ],
        apply_func=gaussian_lowpass,
    ),
    OperationDefinition(
        key="gauss_highpass",
        label="Gaussian High-pass Filter",
        category="Fourier",
        parameters=[
            ParameterDefinition("cutoff", "Cutoff Frequency", "float", 1.0, 200.0, 30.0, 1.0, decimals=1),
        ],
        apply_func=gaussian_highpass,
    ),
    OperationDefinition(
        key="gabor",
        label="Gabor Filter",
        category="Directional Detection",
        parameters=[
            ParameterDefinition("kernel", "Kernel Size", "int", 3, 51, 21, 2),
            ParameterDefinition("sigma", "Sigma", "float", 0.1, 10.0, 4.0, 0.1, decimals=2),
            ParameterDefinition("theta", "Orientation (deg)", "float", 0.0, 180.0, 0.0, 1.0, decimals=1),
            ParameterDefinition("wavelength", "Wavelength", "float", 1.0, 30.0, 10.0, 0.5, decimals=2),
            ParameterDefinition("gamma", "Gamma", "float", 0.1, 1.0, 0.5, 0.05, decimals=2),
            ParameterDefinition("psi", "Phase (deg)", "float", 0.0, 360.0, 0.0, 1.0, decimals=1),
        ],
        apply_func=gabor_filter,
    ),
    OperationDefinition(
        key="dominant_direction_cleanup",
        label="Dominant Direction Cleanup",
        category="User Defined Processing",
        parameters=[
            ParameterDefinition("kernel", "Kernel Size", "int", 3, 151, 51, 2),
            ParameterDefinition("sigma", "Sigma", "float", 0.1, 10.0, 4.0, 0.1, decimals=2),
            ParameterDefinition("wavelength", "Wavelength", "float", 1.0, 40.0, 12.0, 0.5, decimals=2),
            ParameterDefinition("gamma", "Gamma", "float", 0.1, 1.5, 0.5, 0.05, decimals=2),
            ParameterDefinition("psi", "Phase (deg)", "float", 0.0, 360.0, 0.0, 1.0, decimals=1),
            ParameterDefinition("samples", "Orientation Samples", "int", 4, 72, 18, 1),
            ParameterDefinition("response_quantile", "Response Quantile", "float", 0.5, 0.99, 0.85, 0.01, decimals=2),
            ParameterDefinition("dilation_iterations", "Mask Dilation Iterations", "int", 0, 10, 1, 1),
            ParameterDefinition("inpaint_radius", "Inpaint Radius", "float", 0.5, 10.0, 3.0, 0.5, decimals=2),
            ParameterDefinition("blend", "Blend With Original", "float", 0.0, 1.0, 0.0, 0.05, decimals=2),
        ],
        apply_func=remove_dominant_direction,
    ),
    OperationDefinition(
        key="mask_highlight_to_source",
        label="Mask highlight to source",
        category="User Defined Processing",
        parameters=[
            ParameterDefinition(
                "color",
                "Highlight Color",
                "choice",
                choices=[
                    ("Red", "red"),
                    ("Green", "green"),
                    ("Blue", "blue"),
                    ("Yellow", "yellow"),
                    ("Magenta", "magenta"),
                    ("Cyan", "cyan"),
                ],
                default="cyan",
            ),
            ParameterDefinition("alpha", "Blend Strength", "float", 0.0, 1.0, 0.6, 0.05, decimals=2),
            ParameterDefinition("threshold", "Mask Threshold", "float", 0.0, 255.0, 128.0, 1.0, decimals=1),
        ],
        apply_func=highlight_mask_on_source,
    ),
    OperationDefinition(
        key="multi_gabor",
        label="Multi-directional Gabor Filter",
        category="User Defined Processing",
        parameters=[
            ParameterDefinition("directions", "Direction Count", "int", 1, 36, 18, 1),
            ParameterDefinition("angle_start", "Start Angle (deg)", "float", 0.0, 180.0, 0.0, 1.0, decimals=1),
            ParameterDefinition("angle_step", "Angle Step (deg)", "float", 1.0, 180.0, 10.0, 1.0, decimals=1),
            ParameterDefinition("kernel", "Kernel Size", "int", 3, 51, 51, 2),
            ParameterDefinition("sigma", "Sigma", "float", 0.1, 10.0, 4.0, 0.1, decimals=2),
            ParameterDefinition("wavelength", "Wavelength", "float", 1.0, 30.0, 10.0, 0.5, decimals=2),
            ParameterDefinition("gamma", "Gamma", "float", 0.1, 1.0, 0.5, 0.05, decimals=2),
            ParameterDefinition("psi", "Phase (deg)", "float", 0.0, 360.0, 0.0, 1.0, decimals=1),
            ParameterDefinition("highpass_cutoff", "High-pass Cutoff", "float", 1.0, 200.0, 30.0, 1.0, decimals=1),
            ParameterDefinition("strong_threshold", "Strong Threshold", "float", 0.0, 255.0, 128.0, 1.0, decimals=1),
            ParameterDefinition("weak_threshold", "Weak Threshold", "float", 0.0, 255.0, 96.0, 1.0, decimals=1),
            ParameterDefinition("use_hysteresis", "Use Hysteresis Mask", "bool", default=True),
            ParameterDefinition("dilate_mask", "Close Mask Before Merge", "bool", default=False),
            ParameterDefinition("dilation_iterations", "Mask Closing Iterations", "int", 1, 10, 1, 1),
            ParameterDefinition("use_chunks", "Process in Chunks", "bool", default=False),
            ParameterDefinition("chunk_size", "Chunk Size", "int", 16, 1024, 256, 16),
            ParameterDefinition("parallel_kernels", "Parallelize Kernels", "bool", default=True),
            ParameterDefinition("kernel_workers", "Kernel Workers (0=auto)", "int", 0, 64, 0, 1),
            ParameterDefinition("exclude_angles", "Exclude Angles", "text", default=""),
        ],
        apply_func=multi_directional_gabor,
    ),
    OperationDefinition(
        key="hough",
        label="Hough Lines",
        category="Directional Detection",
        parameters=[
            ParameterDefinition("threshold", "Threshold", "int", 10, 200, 80, 1),
            ParameterDefinition("min_length", "Minimum Length", "int", 10, 300, 30, 5),
            ParameterDefinition("max_gap", "Maximum Gap", "int", 1, 100, 10, 1),
        ],
        apply_func=hough_transform,
    ),
    OperationDefinition(
        key="threshold",
        label="Binary Threshold",
        category="Thresholding",
        parameters=[
            ParameterDefinition("threshold", "Threshold", "float", 0.0, 255.0, 128.0, 1.0, decimals=1),
        ],
        apply_func=binary_threshold,
    ),
    OperationDefinition(
        key="brightness_threshold",
        label="Brightness Threshold",
        category="Thresholding",
        parameters=[
            ParameterDefinition("threshold", "Threshold", "float", 0.0, 255.0, 50.0, 1.0, decimals=1),
        ],
        apply_func=brightness_threshold,
    ),
    OperationDefinition(
        key="lower_threshold_limit",
        label="Lower Threshold Limit",
        category="Thresholding",
        parameters=[
            ParameterDefinition(
                "mode",
                "Threshold Source",
                "choice",
                choices=[
                    ("Global Mean", "global_mean"),
                    ("Local Mean", "local_mean"),
                    ("Manual Input", "manual"),
                ],
                default="global_mean",
            ),
            ParameterDefinition("manual_value", "Manual Threshold", "float", 0.0, 255.0, 128.0, 1.0, decimals=1),
            ParameterDefinition("window", "Window Size", "int", 1, 51, 7, 2),
        ],
        apply_func=lower_threshold_limit,
    ),
    OperationDefinition(
        key="upper_threshold_limit",
        label="Upper Threshold Limit",
        category="Thresholding",
        parameters=[
            ParameterDefinition(
                "mode",
                "Threshold Source",
                "choice",
                choices=[
                    ("Global Mean", "global_mean"),
                    ("Local Mean", "local_mean"),
                    ("Manual Input", "manual"),
                ],
                default="global_mean",
            ),
            ParameterDefinition("manual_value", "Manual Threshold", "float", 0.0, 255.0, 128.0, 1.0, decimals=1),
            ParameterDefinition("window", "Window Size", "int", 1, 51, 7, 2),
        ],
        apply_func=upper_threshold_limit,
    ),
    OperationDefinition(
        key="remove_small_components",
        label="Connected Components",
        category="Thresholding",
        parameters=[
            ParameterDefinition("min_size", "Minimum Area", "int", 1, 5000, 100, 10),
            ParameterDefinition("by_chunk", "Process by Chunk", "bool", default=True),
            ParameterDefinition("chunk_size", "Chunk Size", "int", 32, 4096, 640, 32),
        ],
        apply_func=remove_small_components,
    ),
]
